// Copyright © 2022 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package flow

import (
	"context"
	"time"

	"github.com/gofrs/uuid"
	"github.com/pkg/errors"

	"github.com/ory/hydra/v2/aead"
	"github.com/ory/hydra/v2/client"
	"github.com/ory/pop/v6"
	"github.com/ory/x/pointerx"
	"github.com/ory/x/sqlcon"
	"github.com/ory/x/sqlxx"
)

// FlowState* constants enumerate the states of a flow. The below graph
// describes possible flow state transitions.
//
// stateDiagram-v2
//  [*] --> DEVICE_UNUSED: GET /oauth2/device/verify
//  DEVICE_UNUSED --> DEVICE_USED: submit user code
//  DEVICE_USED --> LOGIN_UNUSED: to verifier
//  [*] --> LOGIN_UNUSED: GET /oauth2/auth
//  LOGIN_UNUSED --> LOGIN_UNUSED: accept login
//  LOGIN_UNUSED --> LOGIN_USED: submit login verifier
//  LOGIN_UNUSED --> LOGIN_ERROR: reject login
//  LOGIN_ERROR --> [*]
//  LOGIN_USED --> CONSENT_UNUSED
//  CONSENT_UNUSED --> CONSENT_UNUSED: accept consent
//  CONSENT_UNUSED --> CONSENT_USED: submit consent verifier
//  CONSENT_UNUSED --> CONSENT_ERROR: reject consent
//  CONSENT_ERROR --> [*]
//  CONSENT_USED --> [*]

type State int16

const (
	// FlowStateLoginInitialized is not used anymore, but is kept for
	// backwards compatibility. New flows start at FlowStateLoginUnused.
	FlowStateLoginInitialized = State(1)

	// FlowStateLoginUnused indicates that the login has been authenticated, but
	// the User Agent hasn't picked up the result yet.
	FlowStateLoginUnused = State(2)

	// FlowStateLoginUsed indicates that the User Agent is requesting consent and
	// Hydra has invalidated the login request. This is a short-lived state
	// because the transition to FlowStateConsentInitialized should happen while
	// handling the request that triggered the transition to FlowStateLoginUsed.
	FlowStateLoginUsed = State(3)

	// FlowStateConsentInitialized is not used anymore, but is kept for
	// backwards compatibility. New flows start at FlowStateConsentUnused.
	FlowStateConsentInitialized = State(4)

	FlowStateConsentUnused = State(5)
	FlowStateConsentUsed   = State(6)

	// DeviceFlowStateInitialized is not used anymore, but is kept for
	// backwards compatibility. New flows start at DeviceFlowStateUnused.
	DeviceFlowStateInitialized = State(7)

	// DeviceFlowStateUnused indicates that the login has been authenticated, but
	// the User Agent hasn't picked up the result yet.
	DeviceFlowStateUnused = State(8)

	// DeviceFlowStateUsed indicates that the User Agent is requesting consent and
	// Hydra has invalidated the login request. This is a short-lived state
	// because the transition to DeviceFlowStateConsentInitialized should happen while
	// handling the request that triggered the transition to DeviceFlowStateUsed.
	DeviceFlowStateUsed = State(9)

	// TODO: Refactor error handling to persist error codes instead of JSON
	// strings. Currently we persist errors as JSON strings in the LoginError
	// and ConsentError fields. This shouldn't be necessary because the different
	// errors are enumerable; most of them have error codes defined in Fosite. It
	// is possible to define a mapping between error codes and the metadata that
	// is currently persisted with each erred Flow. This mapping would be used in
	// GetConsentRequest, HandleConsentRequest, GetHandledLoginRequest, etc. An
	// ErrorContext field can be introduced later if it becomes necessary.
	// If the above is implemented, merge the LoginError and ConsentError fields
	// and use the following FlowStates when converting to/from
	// [Handled]{Login|Consent}Request:
	FlowStateLoginError   = State(128)
	FlowStateConsentError = State(129)
)

func (s State) ConsentWasUsed() bool { return s == FlowStateConsentUsed || s == FlowStateConsentError }
func (s State) LoginWasUsed() bool   { return s == FlowStateLoginUsed || s == FlowStateLoginError }

func (s State) IsAny(expected ...State) error {
	for _, e := range expected {
		if s == e {
			return nil
		}
	}
	return errors.Errorf("invalid flow state: expected one of %v, got %d", expected, s)
}

// Flow is an abstraction used in the persistence layer to unify LoginRequest,
// HandledLoginRequest, ConsentRequest, and AcceptOAuth2ConsentRequest.
//
// TODO: Deprecate the structs that are made obsolete by the Flow concept.
// Context: Before Flow was introduced, the API and the database used the same
// structs, LoginRequest and HandledLoginRequest. These two tables and structs
// were merged into a new concept, Flow, in order to optimize the persistence
// layer. We currently limit the use of Flow to the persistence layer and keep
// using the original structs in the API in order to minimize the impact of the
// database refactoring on the API.
type Flow struct {
	// ID is the identifier of the login request.
	//
	// The struct field is named ID for compatibility with gobuffalo/pop, and is
	// the primary key in the database.
	//
	// The database column should be named `login_challenge_id`, but is not for
	// historical reasons.
	//
	// This is not the same as the login session ID.
	ID  string    `db:"login_challenge" json:"i"`
	NID uuid.UUID `db:"nid" json:"n"`

	// RequestedScope contains the OAuth 2.0 Scope requested by the OAuth 2.0 Client.
	//
	// required: true
	RequestedScope sqlxx.StringSliceJSONFormat `db:"requested_scope" json:"rs,omitempty"`

	// RequestedAudience contains the access token audience as requested by the OAuth 2.0 Client.
	//
	// required: true
	RequestedAudience sqlxx.StringSliceJSONFormat `db:"requested_at_audience" json:"ra,omitempty"`

	// LoginSkip, if true, implies that the client has requested the same scopes from the same user previously.
	// If true, you can skip asking the user to grant the requested scopes, and simply forward the user to the redirect URL.
	//
	// This feature allows you to update / set session information.
	//
	// required: true
	LoginSkip bool `db:"-" json:"ls,omitempty"`

	// Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope
	// requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type
	// when accepting the login request, or the request will fail.
	//
	// required: true
	Subject string `db:"subject" json:"s,omitempty"`

	// OpenIDConnectContext provides context for the (potential) OpenID Connect context. Implementation of these
	// values in your app are optional but can be useful if you want to be fully compliant with the OpenID Connect spec.
	OpenIDConnectContext *OAuth2ConsentRequestOpenIDConnectContext `db:"oidc_context" json:"oc"`

	// Client is the OAuth 2.0 Client that initiated the request.
	//
	// required: true
	Client   *client.Client `db:"-" json:"c,omitempty"`
	ClientID string         `db:"client_id" json:"ci,omitempty"`

	// RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which
	// initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but
	// might come in handy if you want to deal with additional request parameters.
	//
	// required: true
	RequestURL string `db:"request_url" json:"r,omitempty"`

	// SessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag)
	// this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false)
	// this will be a new random value. This value is used as the "sid" parameter in the ID Token and in OIDC Front-/Back-
	// channel logout. Its value can generally be used to associate consecutive login requests by a certain user.
	SessionID sqlxx.NullString `db:"login_session_id" json:"si,omitempty"`

	// IdentityProviderSessionID is the session ID of the end-user that authenticated.
	// If specified, we will use this value to propagate the logout.
	IdentityProviderSessionID sqlxx.NullString `db:"-" json:"is,omitempty"`

	LoginCSRF string `db:"-" json:"lc,omitempty"`

	RequestedAt time.Time `db:"requested_at" json:"ia,omitempty"`

	State State `db:"-" json:"q,omitempty"`

	// LoginRemember, if set to true, tells ORY Hydra to remember this user by telling the user agent (browser) to store
	// a cookie with authentication data. If the same user performs another OAuth 2.0 Authorization Request, he/she
	// will not be asked to log in again.
	LoginRemember bool `db:"-" json:"lr,omitempty"`

	// LoginRememberFor sets how long the authentication should be remembered for in seconds. If set to `0`, the
	// authorization will be remembered for the duration of the browser session (using a session cookie).
	LoginRememberFor int `db:"-" json:"lf,omitempty"`

	// LoginExtendSessionLifespan, if set to true, session cookie expiry time will be updated when session is
	// refreshed (login skip=true).
	LoginExtendSessionLifespan bool `db:"-" json:"ll,omitempty"`

	// ACR sets the Authentication AuthorizationContext Class Reference value for this authentication session. You can use it
	// to express that, for example, a user authenticated using two factor authentication.
	ACR string `db:"acr" json:"a,omitempty"`

	// AMR sets the Authentication Methods References value for this
	// authentication session. You can use it to specify the method a user used to
	// authenticate. For example, if the acr indicates a user used two factor
	// authentication, the amr can express they used a software-secured key.
	AMR sqlxx.StringSliceJSONFormat `db:"amr" json:"am,omitempty"`

	// ForceSubjectIdentifier forces the "pairwise" user ID of the end-user that authenticated. The "pairwise" user ID refers to the
	// (Pairwise Identifier Algorithm)[http://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg] of the OpenID
	// Connect specification. It allows you to set an obfuscated subject ("user") identifier that is unique to the client.
	//
	// Please note that this changes the user ID on endpoint /userinfo and sub claim of the ID Token. It does not change the
	// sub claim in the OAuth 2.0 Introspection.
	//
	// Per default, ORY Hydra handles this value with its own algorithm. In case you want to set this yourself
	// you can use this field. Please note that setting this field has no effect if `pairwise` is not configured in
	// ORY Hydra or the OAuth 2.0 Client does not expect a pairwise identifier (set via `subject_type` key in the client's
	// configuration).
	//
	// Please also be aware that ORY Hydra is unable to properly compute this value during authentication. This implies
	// that you have to compute this value on every authentication process (probably depending on the client ID or some
	// other unique value).
	//
	// If you fail to compute the proper value, then authentication processes which have id_token_hint set might fail.
	ForceSubjectIdentifier string `db:"-" json:"fs,omitempty"`

	// Context is an optional object which can hold arbitrary data. The data will be made available when fetching the
	// consent request under the "context" field. This is useful in scenarios where login and consent endpoints share
	// data.
	Context sqlxx.JSONRawMessage `db:"context" json:"ct"`

	LoginError           *RequestDeniedError `db:"-" json:"le,omitempty"`
	LoginAuthenticatedAt sqlxx.NullTime      `db:"-" json:"la,omitempty"`

	// DeviceChallengeID is the device request's challenge ID
	DeviceChallengeID sqlxx.NullString `db:"device_challenge_id" json:"di,omitempty"`
	// DeviceCodeRequestID is the device request's ID
	DeviceCodeRequestID sqlxx.NullString `db:"device_code_request_id" json:"dr,omitempty"`
	// DeviceCSRF is the device request's CSRF
	DeviceCSRF sqlxx.NullString `db:"-" json:"dc,omitempty"`
	// DeviceHandledAt contains the timestamp the device user_code verification request was handled
	DeviceHandledAt sqlxx.NullTime `db:"-" json:"dh,omitempty"`

	// ConsentRequestID is the identifier of the consent request.
	// The database column should be named `consent_request_id`, but is not for historical reasons.
	ConsentRequestID sqlxx.NullString `db:"consent_challenge_id" json:"cc,omitempty"`
	// ConsentSkip, if true, implies that the client has requested the same scopes from the same user previously.
	// If true, you must not ask the user to grant the requested scopes. You must however either allow or deny the
	// consent request using the usual API call.
	ConsentSkip bool             `db:"consent_skip" json:"cs,omitempty"`
	ConsentCSRF sqlxx.NullString `db:"-" json:"cr,omitempty"`

	// GrantedScope sets the scope the user authorized the client to use. Should be a subset of `requested_scope`.
	GrantedScope sqlxx.StringSliceJSONFormat `db:"granted_scope" json:"gs,omitempty"`

	// GrantedAudience sets the audience the user authorized the client to use. Should be a subset of `requested_access_token_audience`.
	GrantedAudience sqlxx.StringSliceJSONFormat `db:"granted_at_audience" json:"ga,omitempty"`

	// ConsentRemember, if set to true, tells ORY Hydra to remember this consent authorization and reuse it if the same
	// client asks the same user for the same, or a subset of, scope.
	ConsentRemember bool `db:"consent_remember" json:"ce,omitempty"`

	// ConsentRememberFor sets how long the consent authorization should be remembered for in seconds. If set to `0`, the
	// authorization will be remembered indefinitely.
	ConsentRememberFor *int `db:"consent_remember_for" json:"cf"`

	// ConsentHandledAt contains the timestamp the consent request was handled.
	ConsentHandledAt sqlxx.NullTime `db:"consent_handled_at" json:"ch,omitempty"`

	ConsentError       *RequestDeniedError      `db:"-" json:"cx"`
	SessionIDToken     sqlxx.MapStringInterface `db:"session_id_token" faker:"-" json:"st"`
	SessionAccessToken sqlxx.MapStringInterface `db:"session_access_token" faker:"-" json:"sa"`
}

// HandleDeviceUserAuthRequest updates the flows fields from a handled request.
func (f *Flow) HandleDeviceUserAuthRequest(h *HandledDeviceUserAuthRequest) error {
	if err := f.State.IsAny(DeviceFlowStateInitialized, DeviceFlowStateUnused); err != nil {
		return err
	}

	f.State = DeviceFlowStateUnused

	f.Client = h.Client
	f.ClientID = h.Client.GetID()
	f.DeviceCodeRequestID = sqlxx.NullString(h.DeviceCodeRequestID)
	f.DeviceHandledAt = sqlxx.NullTime(time.Now().UTC())
	f.RequestedScope = h.RequestedScope
	f.RequestedAudience = h.RequestedAudience

	return nil
}

// InvalidateDeviceRequest shifts the flow state to DeviceFlowStateUsed. This
// transition is executed upon device completion.
func (f *Flow) InvalidateDeviceRequest() error {
	if err := f.State.IsAny(DeviceFlowStateUnused); err != nil {
		return err
	}
	f.State = DeviceFlowStateUsed
	return nil
}

func (f *Flow) HandleLoginRequest(h *HandledLoginRequest) error {
	if err := f.State.IsAny(FlowStateLoginInitialized, FlowStateLoginUnused, FlowStateLoginError); err != nil {
		return err
	}

	if f.Subject != "" && h.Subject != "" && f.Subject != h.Subject {
		return errors.Errorf("flow Subject %s does not match the HandledLoginRequest Subject %s", f.Subject, h.Subject)
	}

	if f.ForceSubjectIdentifier != "" && h.ForceSubjectIdentifier != "" && f.ForceSubjectIdentifier != h.ForceSubjectIdentifier {
		return errors.Errorf("flow ForceSubjectIdentifier %s does not match the HandledLoginRequest ForceSubjectIdentifier %s", f.ForceSubjectIdentifier, h.ForceSubjectIdentifier)
	}

	f.State = FlowStateLoginUnused

	if f.Context != nil {
		f.Context = h.Context
	}

	f.Subject = h.Subject
	f.ForceSubjectIdentifier = h.ForceSubjectIdentifier

	f.IdentityProviderSessionID = sqlxx.NullString(h.IdentityProviderSessionID)
	f.LoginRemember = h.Remember
	f.LoginRememberFor = h.RememberFor
	f.LoginExtendSessionLifespan = h.ExtendSessionLifespan
	f.ACR = h.ACR
	f.AMR = h.AMR
	return nil
}

func (f *Flow) HandleLoginError(er *RequestDeniedError) error {
	if err := f.State.IsAny(FlowStateLoginInitialized, FlowStateLoginUnused, FlowStateLoginError); err != nil {
		return err
	}

	f.State = FlowStateLoginError

	f.LoginError = er

	// force-reset values
	f.Subject = ""
	f.ForceSubjectIdentifier = ""
	f.LoginAuthenticatedAt = sqlxx.NullTime{}
	f.IdentityProviderSessionID = ""
	f.LoginRemember = false
	f.LoginRememberFor = 0
	f.LoginExtendSessionLifespan = false
	f.ACR = ""
	f.AMR = nil

	return nil
}

func (f *Flow) GetLoginRequest() *LoginRequest {
	return &LoginRequest{
		ID:                   f.ID,
		RequestedScope:       f.RequestedScope,
		RequestedAudience:    f.RequestedAudience,
		Skip:                 f.LoginSkip,
		Subject:              f.Subject,
		OpenIDConnectContext: f.OpenIDConnectContext,
		Client:               f.Client,
		RequestURL:           f.RequestURL,
		SessionID:            f.SessionID,
	}
}

// InvalidateLoginRequest shifts the flow state to FlowStateLoginUsed. This
// transition is executed upon login completion.
func (f *Flow) InvalidateLoginRequest() error {
	if err := f.State.IsAny(FlowStateLoginUnused, FlowStateLoginError); err != nil {
		return err
	}

	if f.State == FlowStateLoginUnused {
		f.State = FlowStateLoginUsed
	} else {
		// FlowStateLoginError is already a terminal state, so we don't need to do anything here.
	}
	return nil
}

func (f *Flow) HandleConsentRequest(r *AcceptOAuth2ConsentRequest) error {
	if err := f.State.IsAny(FlowStateConsentInitialized, FlowStateConsentUnused, FlowStateConsentError); err != nil {
		return err
	}

	f.State = FlowStateConsentUnused

	f.GrantedScope = r.GrantedScope
	f.GrantedAudience = r.GrantedAudience
	f.ConsentRemember = r.Remember
	f.ConsentRememberFor = &r.RememberFor
	f.ConsentHandledAt = sqlxx.NullTime(time.Now().UTC())
	f.ConsentError = nil
	if r.Context != nil {
		f.Context = r.Context
	}

	if r.Session != nil {
		f.SessionIDToken = r.Session.IDToken
		f.SessionAccessToken = r.Session.AccessToken
	}
	return nil
}

func (f *Flow) HandleConsentError(er *RequestDeniedError) error {
	if err := f.State.IsAny(FlowStateConsentInitialized, FlowStateConsentUnused, FlowStateConsentError); err != nil {
		return err
	}

	f.State = FlowStateConsentError

	f.ConsentError = er
	f.ConsentHandledAt = sqlxx.NullTime(time.Now().UTC())

	// force-reset values
	f.GrantedScope = nil
	f.GrantedAudience = nil
	f.ConsentRemember = false
	f.ConsentRememberFor = nil

	return nil
}

func (f *Flow) InvalidateConsentRequest() error {
	if err := f.State.IsAny(FlowStateConsentUnused, FlowStateConsentError); err != nil {
		return err
	}

	if f.State == FlowStateConsentUnused {
		f.State = FlowStateConsentUsed
	} else {
		// FlowStateConsentError is already a terminal state, so we don't need to do anything here.
	}
	return nil
}

func (f *Flow) GetConsentRequest(challenge string) *OAuth2ConsentRequest {
	cs := OAuth2ConsentRequest{
		Challenge:            challenge,
		ConsentRequestID:     f.ConsentRequestID.String(),
		RequestedScope:       f.RequestedScope,
		RequestedAudience:    f.RequestedAudience,
		Skip:                 f.ConsentSkip,
		Subject:              f.Subject,
		OpenIDConnectContext: f.OpenIDConnectContext,
		Client:               f.Client,
		RequestURL:           f.RequestURL,
		LoginChallenge:       sqlxx.NullString(f.ID),
		LoginSessionID:       f.SessionID,
		ACR:                  f.ACR,
		AMR:                  f.AMR,
		Context:              f.Context,
	}
	// set some defaults for the API
	if cs.RequestedAudience == nil {
		cs.RequestedAudience = []string{}
	}
	if cs.AMR == nil {
		cs.AMR = []string{}
	}
	return &cs
}

func (Flow) TableName() string {
	return "hydra_oauth2_flow"
}

func (f *Flow) BeforeSave(_ *pop.Connection) error {
	if f.Client != nil {
		f.ClientID = f.Client.GetID()
	}
	if f.State == FlowStateLoginUnused && string(f.Context) == "" {
		f.Context = sqlxx.JSONRawMessage("{}")
	}
	return nil
}

func (f *Flow) AfterFind(c *pop.Connection) error {
	// TODO Populate the client field in FindInDB and FindByConsentChallengeID in
	// order to avoid accessing the database twice.
	if f.ClientID == "" {
		return nil
	}
	f.AfterSave(c)
	f.Client = &client.Client{}
	return sqlcon.HandleError(c.Where("id = ? AND nid = ?", f.ClientID, f.NID).First(f.Client))
}

func (f *Flow) AfterSave(_ *pop.Connection) {
	if f.SessionAccessToken == nil {
		f.SessionAccessToken = make(map[string]interface{})
	}
	if f.SessionIDToken == nil {
		f.SessionIDToken = make(map[string]interface{})
	}
}

type CipherProvider interface {
	FlowCipher() *aead.XChaCha20Poly1305
}

// ToDeviceChallenge converts the flow into a device challenge.
func (f *Flow) ToDeviceChallenge(ctx context.Context, cipherProvider CipherProvider) (string, error) {
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsDeviceChallenge)
}

// ToDeviceVerifier converts the flow into a device verifier.
func (f *Flow) ToDeviceVerifier(ctx context.Context, cipherProvider CipherProvider) (string, error) {
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsDeviceVerifier)
}

// ToLoginChallenge converts the flow into a login challenge.
func (f Flow) ToLoginChallenge(ctx context.Context, cipherProvider CipherProvider) (challenge string, err error) {
	if f.Client != nil {
		f.ClientID = f.Client.GetID()
	}
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsLoginChallenge)
}

// ToLoginVerifier converts the flow into a login verifier.
func (f Flow) ToLoginVerifier(ctx context.Context, cipherProvider CipherProvider) (verifier string, err error) {
	if f.Client != nil {
		f.ClientID = f.Client.GetID()
	}
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsLoginVerifier)
}

// ToConsentChallenge converts the flow into a consent challenge.
func (f Flow) ToConsentChallenge(ctx context.Context, cipherProvider CipherProvider) (challenge string, err error) {
	if f.Client != nil {
		f.ClientID = f.Client.GetID()
	}
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsConsentChallenge)
}

// ToConsentVerifier converts the flow into a consent verifier.
func (f Flow) ToConsentVerifier(ctx context.Context, cipherProvider CipherProvider) (verifier string, err error) {
	if f.Client != nil {
		f.ClientID = f.Client.GetID()
	}
	return Encode(ctx, cipherProvider.FlowCipher(), f, AsConsentVerifier)
}

func (f Flow) ToListConsentSessionResponse() *OAuth2ConsentSession {
	s := &OAuth2ConsentSession{
		ConsentRequestID: f.ConsentRequestID.String(),
		GrantedScope:     f.GrantedScope,
		GrantedAudience:  f.GrantedAudience,
		RememberFor:      pointerx.Deref(f.ConsentRememberFor),
		Session:          &AcceptOAuth2ConsentRequestSession{AccessToken: f.SessionAccessToken, IDToken: f.SessionIDToken},
		Remember:         f.ConsentRemember,
		HandledAt:        f.ConsentHandledAt,
		Context:          f.Context,
		ConsentRequest:   f.GetConsentRequest( /* No longer available and no longer needed: challenge =  */ ""),
	}
	s.ConsentRequest.Client.Secret = "" // do not leak client secret in response
	// set some defaults for the API
	if s.GrantedAudience == nil {
		s.GrantedAudience = []string{}
	}
	return s
}
